//
// generate-qa.js
//
// 功能概述：
// - 遍历 data 目录下各级子目录中的 chat.json 文件；
// - 将每个 chat.json 的消息按块分段，调用大模型抽取问答对（Q/A）及相似问（indexes）；
// - 对抽取结果进行去重与归一化；
// - 在 chat.json 同目录生成 qa.csv（三列：q,a,indexes）。
//
// 关键实现点：
// - 支持最小化测试（--file/--dir/--limit/--messages/--first-chunk/--chunk-size）。
// - 并发控制：块并发（-c/--concurrency）、文件并发（-C/--file-concurrency）。
// - 鲁棒性：失败重试（5 次，指数退避）、请求超时（2 分钟）、更强的 JSON 容错解析（支持 Markdown fenced、JSON5、对象包裹、字段别名）。
// - 角色约束：仅从“客户侧消息”提炼 Q；仅从“铭晖律所刘老师”的后续消息提炼 A；语音类消息先纠错。
//
// 使用示例：
//   单文件最小化测试：
//     node generate-qa.js --file "data\\刘老师_transed\\ 而已￡？？(7881301823921873)\\chat.json" --first-chunk --messages 80 --chunk-size 80
//   目录并发处理：
//     node generate-qa.js --dir "data\\刘老师_transed" --limit 3 -C 2 -c 3 --messages 120 --chunk-size 60
//
// 运行前准备：
// - 在文件顶部 BASE_URL、API_KEY、MODEL 设置网关与模型，或按你的环境配置。
//

const fs = require('fs');
const path = require('path');
const OpenAI = require('openai');
const JSON5 = require('json5');

// 日志记录：打印一般信息
function logMessage(message) {
    console.log(`[${new Date().toISOString()}] ${message}`);
}

// 日志记录：打印错误并写入 error.log
function logError(message, error = null) {
    const errorMessage = `[${new Date().toISOString()}] ERROR: ${message}${error ? ' - ' + error.message : ''}`;
    console.error(errorMessage);
    try {
        fs.appendFileSync('error.log', errorMessage + '\n');
    } catch (writeError) {
        console.error('无法写入错误日志文件:', writeError.message);
    }
}

// 基本参数（可以替换为你的网关/模型/密钥）
const BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1";
const API_KEY = "sk-8146396371b044adbb00302c767732a0";
const MODEL = "deepseek-v3.1";
const DEFAULT_RETRIES = 5;
const DEFAULT_TIMEOUT_MS = 2 * 60 * 1000; // 2 分钟
const FORCE_JSON_ENV = String(process.env.LLM_FORCE_JSON || '').toLowerCase();
const FORCE_JSON = FORCE_JSON_ENV === '1' || FORCE_JSON_ENV === 'true' || FORCE_JSON_ENV === 'yes';

// Promise 版 sleep，用于退避等待
function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

// 初始化大模型客户端（OpenAI 兼容 SDK），设置基础 URL、API Key 与默认超时
function createClient() {
    try {
        const client = new OpenAI({
            apiKey: API_KEY,
            baseURL: BASE_URL || undefined,
            timeout: DEFAULT_TIMEOUT_MS
        });
        return client;
    } catch (error) {
        logError('初始化大模型客户端失败', error);
        throw error;
    }
}

// 递归查找所有 chat.json 文件
function findChatJsonFiles(dir) {
    const jsonFiles = [];
    try {
        const items = fs.readdirSync(dir);
        for (const item of items) {
            const fullPath = path.join(dir, item);
            const stat = fs.statSync(fullPath);
            if (stat.isDirectory()) {
                jsonFiles.push(...findChatJsonFiles(fullPath));
            } else if (item === 'chat.json') {
                jsonFiles.push(fullPath);
            }
        }
    } catch (error) {
        logError(`读取目录失败: ${dir}`, error);
    }
    return jsonFiles;
}

// 将问答对数组写入 CSV（q,a,indexes 三列；indexes 用“ | ”连接）
function toCsv(rows) {
    const headers = ['q', 'a', 'indexes'];
    const escape = (value) => {
        if (value === null || value === undefined) return '';
        const str = String(value);
        // 双引号转义为两个双引号，并整体包裹双引号
        return '"' + str.replace(/"/g, '""') + '"';
    };
    const lines = [headers.join(',')];
    for (const row of rows) {
        const indexes = Array.isArray(row.indexes) ? row.indexes.join(' | ') : (row.indexes || '');
        lines.push([
            escape(row.q),
            escape(row.a),
            escape(indexes)
        ].join(','));
    }
    return lines.join('\n');
}

// 将消息压缩为提示词输入（按行：index\t时间\t发送者\t类型\t内容）
function deriveRole(sender) {
    const s = String(sender || '')
        .replace(/[\u200B-\u200D\uFEFF]/g, '')
        .trim();
    if (s.includes('铭晖') || s.includes('刘老师')) return '客服';
    return '客户';
}

function serializeMessages(messages, start, end) {
    const slice = messages.slice(start, end);
    return slice.map((m, idx) => {
        const safeContent = (m.content || '').replace(/[\n\r]+/g, ' ').trim();
        const role = deriveRole(m.sender);
        // 字段顺序：index\t时间\t发送者\t角色\t类型\t内容
        return `${start + idx}\t${m.send_time || ''}\t${m.sender || ''}\t${role}\t${m.type || 'txt'}\t${safeContent}`;
    }).join('\n');
}

// 构造提示词：
// - 角色固定：仅“铭晖律所刘老师”为回答者（A），其余 sender 为客户侧（Q 候选）。
// - 语音消息先纠错再提炼。
// - 仅返回严格 JSON 数组，禁止 Markdown 与说明文字。
function buildPrompt(serialized) {
    const system = `你是一个中文客服对话整理助手，任务是从原始聊天记录中抽取问答对并生成相似问题。\n\n严格遵循：\n1) 角色以“角色”字段为准：role=客服 的消息为客服答复（A）；role=客户 的消息为客户侧（Q 候选）。\n2) 仅从 role=客户 的消息中提炼问题 Q；在其后的 5-10 条消息内，寻找最近的 role=客服 的相关答复作为 A。若客服有多条连续答复，与该问题强相关的内容应合并为一个 A；若无法找到对应答复则丢弃该问题。\n3) 客户若以多条消息围绕同一意图进行提问，可合并为一个 Q；同义/重复问题去重。\n4) 对于 type 为 voice 的消息，先进行错别字与语音识别误差的纠正，尽量还原真实语义，再用于提炼。\n5) 为每个 Q 生成 3-5 个相似问法（indexes，字符串数组），保持语义一致且表达多样。\n6) 输出严格的 JSON 数组，数组元素形如 {q: string, a: string, indexes: string[]}。\n7) 只输出 JSON，不要任何解释或其他文本；禁止输出 Markdown 代码块（例如以 \`\`\` 开头的块）。\n8) 输出必须以 [ 开头、以 ] 结尾；若无法抽取则输出空数组 []。`;

    const user = `以下为一段按行给出的聊天记录，字段以制表符分隔：index\t时间\t发送者\t角色\t类型\t内容。\n请抽取高质量问答并返回 JSON：\n\n${serialized}`;

    return { system, user };
}

// 兜底提示词：若严格配对无法抽取，则基于“role=客服”的消息，总结可发布为 FAQ 的问答
function buildFallbackPrompt(serialized) {
    const system = `你是一个中文客服对话内容整理助手。当严格的“客户问-客服答”配对无法抽取时，请基于客服（role=客服）的消息，总结能够发布在官网或话术库中的常见问答。\n\n要求：\n1) 从客服消息中提炼出清晰的答案 A，并反推匹配的高质量客户问题 Q（可用常见问法来拟合）。\n2) 生成 3-5 个相似问法（indexes，字符串数组）。\n3) 对于 type 为 voice 的消息，先纠正识别错误，再纳入提炼。\n4) 输出严格 JSON 数组，元素 {q: string, a: string, indexes: string[]}；禁止解释与 Markdown。\n5) 无法总结则返回空数组 []。`;

    const user = `以下为一段按行给出的聊天记录，字段以制表符分隔：index\t时间\t发送者\t角色\t类型\t内容。\n请基于 role=客服 的消息，总结可复用的 FAQ 问答并返回 JSON：\n\n${serialized}`;

    return { system, user };
}

// 调用大模型抽取（失败重试 5 次 + 请求级超时）
async function extractQA(client, messagesChunk, debugOptions = null, useFallback = false) {
    const { system, user } = useFallback ? buildFallbackPrompt(messagesChunk) : buildPrompt(messagesChunk);
    for (let attempt = 1; attempt <= DEFAULT_RETRIES; attempt++) {
        try {
            const resp = await client.chat.completions.create({
                model: MODEL,
                temperature: 0.2,
                messages: [
                    { role: 'system', content: system },
                    { role: 'user', content: user }
                ]
            }, { timeout: DEFAULT_TIMEOUT_MS });
            const text = resp.choices?.[0]?.message?.content || '';
            if (debugOptions && typeof debugOptions.onRaw === 'function') {
                try { debugOptions.onRaw(text); } catch(_) {}
            }
            const json = safeParseJsonArray(text);
            return json;
        } catch (error) {
            if (attempt >= DEFAULT_RETRIES) {
                logError(`大模型抽取失败（已重试 ${DEFAULT_RETRIES} 次）`, error);
                return [];
            }
            const base = 1000; // 1s
            const delay = Math.min(30000, base * Math.pow(2, attempt - 1));
            const jitter = Math.floor(Math.random() * 250);
            logMessage(`抽取失败，第 ${attempt} 次，将在 ${delay + jitter}ms 后重试...`);
            await sleep(delay + jitter);
        }
    }
}

// 以下为一组 JSON 容错解析工具，尽可能从模型输出中提取到合法的 QA 数组
function extractCodeFence(raw) {
    // 提取 ```json ... ``` 或 ``` ... ``` 内的内容
    const fenceJson = raw.match(/```json\s*([\s\S]*?)```/i);
    if (fenceJson) return fenceJson[1];
    const fenceAny = raw.match(/```\s*([\s\S]*?)```/);
    if (fenceAny) return fenceAny[1];
    return null;
}

function coerceToArray(maybe) {
    if (Array.isArray(maybe)) return maybe;
    if (!maybe || typeof maybe !== 'object') return [];
    // 常见包裹字段
    const candidates = ['data', 'result', 'results', 'items', 'list', 'qa'];
    for (const key of candidates) {
        if (Array.isArray(maybe[key])) return maybe[key];
    }
    return [];
}

function normalizePairs(arr) {
    const result = [];
    const splitIndexes = (v) => {
        if (Array.isArray(v)) return v.map(x => String(x).trim()).filter(Boolean);
        if (typeof v === 'string') {
            return v.split(/\||,|，|、|;|；|\n/g).map(s => s.trim()).filter(Boolean);
        }
        return [];
    };
    for (const item of arr) {
        if (!item) continue;
        let q = item.q ?? item.question ?? item['问题'] ?? item['问'];
        let a = item.a ?? item.answer ?? item['回答'] ?? item['答'];
        let indexes = item.indexes ?? item.similar ?? item['相似问'] ?? item['相似问题'];
        if (q === undefined && typeof item === 'string') {
            // 可能是单字符串，不处理
            continue;
        }
        if (q == null || a == null) continue;
        q = String(q).trim();
        a = String(a).trim();
        const idxArr = splitIndexes(indexes);
        if (!q || !a) continue;
        result.push({ q, a, indexes: idxArr });
    }
    return result;
}

function safeParseJsonArray(text) {
    if (!text) return [];
    let raw = String(text).trim();
    // 剥离常见推理标签与包装，避免污染 JSON
    raw = stripReasoningArtifacts(raw);
    // 1) 去除围栏
    const fenced = extractCodeFence(raw);
    if (fenced) raw = fenced.trim();
    // 2) 直接 JSON.parse
    try {
        const direct = JSON.parse(raw);
        const arr = Array.isArray(direct) ? direct : coerceToArray(direct);
        return normalizePairs(arr);
    } catch (_) {}
    // 3) 提取首尾 [] 片段再 parse
    try {
        const start = raw.indexOf('[');
        const end = raw.lastIndexOf(']');
        if (start >= 0 && end > start) {
            const slice = raw.slice(start, end + 1);
            const arr = JSON.parse(slice);
            return normalizePairs(Array.isArray(arr) ? arr : coerceToArray(arr));
        }
    } catch (_) {}
    // 4) JSON5 容错解析（整体 & 片段）
    try {
        const j5 = JSON5.parse(raw);
        const arr = Array.isArray(j5) ? j5 : coerceToArray(j5);
        const norm = normalizePairs(arr);
        if (norm.length) return norm;
    } catch (_) {}
    try {
        const start = raw.indexOf('[');
        const end = raw.lastIndexOf(']');
        if (start >= 0 && end > start) {
            const slice = raw.slice(start, end + 1);
            const j5 = JSON5.parse(slice);
            const arr = Array.isArray(j5) ? j5 : coerceToArray(j5);
            const norm = normalizePairs(arr);
            if (norm.length) return norm;
        }
    } catch (_) {}
    // 5) 如果是字符串里再包一层 JSON
    try {
        const parsed = JSON.parse(raw);
        if (typeof parsed === 'string') {
            return safeParseJsonArray(parsed);
        }
    } catch (_) {}
    logError('JSON 解析失败，返回空数组');
    return [];
}

// 剥离 reasoning 型模型常见的包裹/标签（如 <think>...)
function stripReasoningArtifacts(s) {
    let t = String(s);
    // 移除 DeepSeek 等模型的 <think> 思考块
    t = t.replace(/<think[\s\S]*?<\/think>/gi, '');
    // 移除潜在的 xml/html 标签（保守做法，仅移除简单标签，不改动内容）
    t = t.replace(/<\/?(\w+)([^>]*)>/g, '');
    return t;
}

// 去重：以 q 文本为键，清洗字段并去空
function deduplicate(pairs) {
    const seen = new Set();
    const result = [];
    for (const item of pairs) {
        const key = (item.q || '').trim();
        if (!key) continue;
        if (seen.has(key)) continue;
        seen.add(key);
        const cleaned = {
            q: key,
            a: (item.a || '').trim(),
            indexes: Array.isArray(item.indexes) ? item.indexes.map(s => String(s).trim()).filter(Boolean) : []
        };
        result.push(cleaned);
    }
    return result;
}

// 处理单个 chat.json：
// - 跳过已存在的 qa.csv（除非 --overwrite）；
// - 可限制总消息条数（--messages）；
// - 分块请求大模型（--chunk-size），支持只跑首块（--first-chunk）；
// - 汇总、去重并写出 qa.csv。
async function processOneFile(client, chatJsonPath, overwrite, options = {}) {
    const dir = path.dirname(chatJsonPath);
    const outputCsv = path.join(dir, 'qa.csv');
    if (!overwrite && fs.existsSync(outputCsv)) {
        logMessage(`⏭️  跳过已存在的文件: ${outputCsv}`);
        return { skipped: true, file: outputCsv };
    }

    let messages;
    try {
        const raw = fs.readFileSync(chatJsonPath, 'utf8');
        messages = JSON.parse(raw);
        if (!Array.isArray(messages)) throw new Error('chat.json 格式应为数组');
    } catch (error) {
        logError(`读取/解析失败: ${chatJsonPath}`, error);
        return { skipped: false, file: outputCsv, success: false };
    }

    // 可选限制消息条数（最小化测试时可仅取前 N 条）
    const limitMsgs = Number.isFinite(options.messagesLimit) && options.messagesLimit > 0 ? options.messagesLimit : null;
    const effectiveMessages = limitMsgs ? messages.slice(0, limitMsgs) : messages;

    // 分块以控制上下文长度与输出大小
    const chunkSize = Number.isFinite(options.chunkSize) && options.chunkSize > 0 ? options.chunkSize : 120;
    const allPairs = [];
    if (options.firstChunkOnly) {
        const start = 0;
        const end = Math.min(chunkSize, effectiveMessages.length);
        const serialized = serializeMessages(effectiveMessages, start, end);
        const debugCb = options.debug ? (raw => {
            try {
                const dbg = path.join(dir, `qa.debug.${Date.now()}.${start}-${end}.txt`);
                fs.writeFileSync(dbg, raw, 'utf8');
            } catch(_) {}
        }) : null;
        const pairs = await extractQA(client, serialized, debugCb ? { onRaw: debugCb } : null);
        if (pairs.length > 0) allPairs.push(...pairs);
    } else {
        for (let start = 0; start < effectiveMessages.length; start += chunkSize) {
            const end = Math.min(start + chunkSize, effectiveMessages.length);
            const serialized = serializeMessages(effectiveMessages, start, end);
            const debugCb = options.debug ? (raw => {
                try {
                    const dbg = path.join(dir, `qa.debug.${Date.now()}.${start}-${end}.txt`);
                    fs.writeFileSync(dbg, raw, 'utf8');
                } catch(_) {}
            }) : null;
            const pairs = await extractQA(client, serialized, debugCb ? { onRaw: debugCb } : null);
            if (pairs.length > 0) {
                allPairs.push(...pairs);
            }
        }
    }

    const uniquePairs = deduplicate(allPairs);
    if (uniquePairs.length === 0) {
        logMessage(`⚠️  未提取到 QA: ${chatJsonPath}`);
        return { skipped: false, file: outputCsv, success: false };
    }

    try {
        const csv = toCsv(uniquePairs);
        fs.writeFileSync(outputCsv, csv, 'utf8');
        logMessage(`✅ 生成 QA: ${outputCsv} （共 ${uniquePairs.length} 条）`);
        return { skipped: false, file: outputCsv, success: true };
    } catch (error) {
        logError(`写入 CSV 失败: ${outputCsv}`, error);
        return { skipped: false, file: outputCsv, success: false };
    }
}

// 主入口：解析 CLI 参数 → 定位文件集合 → 顺序处理并统计结果
async function main() {
    const args = process.argv.slice(2);
    const overwrite = args.includes('--overwrite') || args.includes('-o');
    const modelArgIndex = args.findIndex(a => a === '--model');
    const customModel = modelArgIndex >= 0 ? args[modelArgIndex + 1] : null;
    const fileArgIndex = args.findIndex(a => a === '--file' || a === '-f');
    const fileArg = fileArgIndex >= 0 ? args[fileArgIndex + 1] : null;
    const dirArgIndex = args.findIndex(a => a === '--dir' || a === '-d');
    const dirArg = dirArgIndex >= 0 ? args[dirArgIndex + 1] : null;
    const limitArgIndex = args.findIndex(a => a === '--limit');
    const limitN = limitArgIndex >= 0 ? parseInt(args[limitArgIndex + 1], 10) : null;
    const msgArgIndex = args.findIndex(a => a === '--messages');
    const messagesLimit = msgArgIndex >= 0 ? parseInt(args[msgArgIndex + 1], 10) : null;
    const chunkArgIndex = args.findIndex(a => a === '--chunk-size');
    const chunkSize = chunkArgIndex >= 0 ? parseInt(args[chunkArgIndex + 1], 10) : null;
    const firstChunkOnly = args.includes('--first-chunk') || args.includes('--first');
    const debug = args.includes('--debug');

    if (!API_KEY) {
        logError('未设置 API Key。请通过环境变量 LLM_API_KEY 或 OPENAI_API_KEY 提供。');
        process.exit(1);
    }

    if (!BASE_URL) {
        logMessage('未设置自定义 BASE_URL，将使用默认官方/SDK 内置地址。可通过 LLM_BASE_URL 配置。');
    }

    if (customModel) {
        process.env.LLM_MODEL = customModel;
        logMessage(`使用自定义模型: ${customModel}`);
    } else {
        logMessage(`使用模型: ${MODEL}`);
    }

    logMessage('开始生成 QA...');
    logMessage(overwrite ? '覆盖模式：将覆盖现有的 qa.csv 文件' : '跳过模式：将跳过已存在的 qa.csv 文件');

    const dataDir = path.join(__dirname, 'data');
    if (!fs.existsSync(dataDir)) {
        logError(`数据目录不存在: ${dataDir}`);
        return;
    }

    const client = createClient();

    // 解析测试范围
    function resolvePathLike(p) {
        if (!p) return null;
        if (path.isAbsolute(p)) return p;
        const p1 = path.join(__dirname, p);
        if (fs.existsSync(p1)) return p1;
        return path.join(dataDir, p);
    }

    let files = [];
    if (fileArg) {
        const filePath = resolvePathLike(fileArg);
        if (!filePath.endsWith('chat.json')) {
            logError('指定的 --file 不是 chat.json 文件');
            return;
        }
        if (!fs.existsSync(filePath)) {
            logError(`指定的文件不存在: ${filePath}`);
            return;
        }
        files = [filePath];
        logMessage(`按文件测试: ${filePath}`);
    } else {
        const root = dirArg ? resolvePathLike(dirArg) : dataDir;
        if (!fs.existsSync(root)) {
            logError(`指定的目录不存在: ${root}`);
            return;
        }
        files = findChatJsonFiles(root);
        logMessage(`搜索目录: ${root}，共发现 ${files.length} 个 chat.json`);
    }

    if (Number.isFinite(limitN) && limitN > 0 && files.length > limitN) {
        files = files.slice(0, limitN);
        logMessage(`应用 --limit=${limitN}，实际处理 ${files.length} 个文件`);
    }

    let success = 0;
    let skipped = 0;
    for (const f of files) {
        logMessage(`处理: ${f}`);
        const r = await processOneFile(client, f, overwrite, {
            messagesLimit,
            chunkSize,
            firstChunkOnly,
            debug
        });
        if (r.skipped) skipped += 1; else if (r.success) success += 1;
    }

    logMessage(`完成！成功: ${success}, 跳过: ${skipped}, 总计: ${files.length}`);
}

if (require.main === module) {
    main();
}

module.exports = {
    findChatJsonFiles,
    serializeMessages,
    buildPrompt,
    extractQA,
    toCsv,
    deduplicate
};


